Fix concurrency races on shared Http/Route singletons#251
Fix concurrency races on shared Http/Route singletons#251
Conversation
Under the Swoole and SwooleCoroutine adapters multiple requests run concurrently in the same process and shared a single Http instance plus the static Router/Route/Hook registries. Per-request data was being written onto those shared objects, so a coroutine yielding on I/O could return to find another request had clobbered its routing state. Four shared mutations are removed: 1. Http::$route — moved to the per-request container (coroutine-local under both Swoole adapters via Coroutine::getContext()), so getRoute() and the match() cache no longer collide between requests. Telemetry in run() now reads through getRoute(). 2. Route::$matchedPath — Router::match() now returns the matched route together with the prepared-path string instead of mutating the singleton Route. Http::match() stashes the path on the request container; execute() reads it via getMatchedPath(). 3. Http::$wildcardRoute — the wildcard branch now clones the singleton before stamping the request URI onto its $path, so two concurrent wildcard requests can't race on the shared route's path. 4. Hook::setParamValue() writeback in getArguments() — removed. The resolved $arguments array is still fed to the action; writing values back onto the shared Hook/Route was the only mutation that made shared hooks unsafe under concurrency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Container as @deprecated Route::setMatchedPath() / Route::getMatchedPath() are no longer called by the framework after Router::match() switched to returning the matched path in its return tuple. Http::$requestContainer was never assigned — per-request state lives on the adapter's container. Left in place for backwards compatibility; flagged for removal in a future major release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
k6 benchmark
|
Greptile SummaryThis PR fixes four concurrency races on shared Confidence Score: 4/5Safe to merge for FPM deployments; Swoole callers should verify against the migration guide before deploying. The concurrency fixes are correctly implemented and all 72 tests pass. The one nuance worth noting is that $match->arguments is populated by-reference inside getArguments() before per-param validation runs, so error hooks will see pre-validation (potentially invalid) candidate values for params that failed — this is intentional and explicitly tested in testErrorHookSeesPartialMatchArgumentsWhenValidationFails, but is subtly different from what the RouteMatch docblock implies. No new P0/P1 logic bugs were found beyond what was already captured in previous review threads. src/Http/Http.php — the getArguments() by-reference population order (write before validate) and the execute() direct-call path (empty $match->path when no prior Router::match()). Important Files Changed
Reviews (14): Last reviewed commit: "Populate \$match->arguments incrementall..." | Re-trigger Greptile |
…iner Removed instead of left as @deprecated — neither has been part of any working code path since the previous commit, and keeping them around just preserves footguns (the matched-path getter would silently return '' on a route returned from Router::match). Removed: - Route::$matchedPath property - Route::setMatchedPath() / Route::getMatchedPath() - Http::$requestContainer property (never assigned) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The framework already exposes per-request resources under plain names
('request', 'response', 'error', 'server', 'route'), so the namespaced
sentinel constants were inconsistent. Drop them and use the same
convention. As a side effect, 'matchedPath' is now injectable into hooks
and actions like any other request resource.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only the framework itself called setRoute (internal cache plumbing in match() and the wildcard branch). Drop it from the public API and the self-referential test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Singletons stay under setResource/getResource (the global container);
per-request values are now setContext (the request container). The
adapters' coroutine-context key and local variable rename to match:
- Http::setRequestResource() -> Http::setContext()
- Adapter\Swoole\Server::REQUEST_CONTAINER_CONTEXT_KEY
-> Adapter\Swoole\Server::CONTEXT_KEY
- same in Adapter\SwooleCoroutine\Server
- $requestContainer locals -> $context
- internal storage key '__utopia_http_request_container'
-> '__utopia_http_context'
setContext is protected, so this is internal-only — no application API
break. Subclasses overriding the old name need to rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Route and matched path are now pure context values — read them via
\$http->getResource('route') / 'matchedPath', or inject them into a
hook/action. Internal call sites that wrote them go through the
context container directly (or via setContext()).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pint catch from CI — sed introduced double quotes when replacing
\$http->getRoute() with \$http->getResource('route').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before, getContainer() did double duty: returning the request-scoped container when inside a coroutine, the global container otherwise. That made request-side reads/writes look like global ones (\$server->getContainer() inside match()), which obscured the scope split. Now: - getContainer() always returns the global singleton container. - getContext() returns the per-request context container (coroutine-local under Swoole, identical to the global one under FPM). Lookups still fall through to the global container's parent chain, so getContext() resolves singletons too. Http internals updated to call getContext() for all per-request reads and writes; the constructor's setup of \$this->container keeps using getContainer() since it's grabbing the global. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier removal of Hook::setParamValue writeback closed the
concurrency hole but stranded a real use case: shutdown hooks that
need the request's full resolved param map (post-validation, post-
coercion) to do label substitution like {request.fileId}.
Populate a name-keyed snapshot of the route's resolved arguments on
the per-request context as 'arguments', set right before the action
runs. Race-free (per-request context container, coroutine-local
under Swoole), no per-Hook mutation, and reuses the values
getArguments() already resolved — no validator double-invocation.
Available via ->inject('arguments') from shutdown / error hooks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The framework was juggling three separate context keys ('route',
'matchedPath', 'arguments') for state that's logically one thing:
"how this request was routed and what its action received." Replace
them with a single immutable RouteMatch value class set on the
context as 'match'.
Shape:
final class RouteMatch {
public function __construct(
public readonly Route $route,
public readonly string $matchedPath,
public readonly array $arguments = [],
) {}
public function withArguments(array $arguments): self;
}
Lifecycle:
- Router::match() now returns ?RouteMatch (was ?array{Route, string})
- Http::match() stores it on the context as 'match' (or null on miss)
- Http::execute() reads it, calls withArguments() once the route's
params are resolved, and re-stores. Synthesizes a RouteMatch if
execute() is called directly without prior match() (test path).
- Wildcard branch in runInternal() builds a RouteMatch around the
cloned wildcard route.
- Telemetry attribution reads it once at the end of run().
Inject via ->inject('match') from any hook/action; access ->route,
->matchedPath, ->arguments. The previous separate 'route' /
'matchedPath' / 'arguments' keys are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Since the property already lives on a class called RouteMatch, the "matched" prefix is redundant. \$match->path reads cleaner than \$match->matchedPath. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The helper had a single caller (Http::execute() updating the arguments map after the action's params resolve) and the constructor already accepts \$arguments. Inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously 'match' was only set after Http::match() ran (or by execute()
when called directly). Code that read it earlier — request hooks, the
global error path triggered by an exception in a request hook,
top-level introspection in app/http.php — got "Failed to find resource"
from getResource('match').
Set it to null up front, alongside 'request' and 'response', so
inject('match') and getResource('match') always return at least null
during a request. The real RouteMatch overwrites it once routing
resolves.
Added testMatchIsNullDuringEarlyErrorBeforeRouting covering the
"error in onRequest hook -> global error handler reads 'match'"
path that motivated this fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-phase population so init hooks can read URI segments via
\$match->arguments without falling back to Route::getPathValues():
- match() : seeds \$arguments with the path values from
Route::getPathValues() — init hooks see them
- execute() (action): replaces \$arguments with the full
resolved+validated set (path + query + body)
right before the action runs — shutdown / error
hooks see the same map the action received
Also seeds path values when execute() is called directly without a
prior match() (test path).
Closes the gap that forced downstream init-hook code (e.g. Appwrite's
api/web request filters substituting databaseId/collectionId from
the URL) to keep calling Route::getPathValues() directly.
Added testInitHookSeesPathValuesInMatchArguments covering the
init -> match.arguments[pathParam] path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\$route and \$path stay readonly — they're genuinely immutable across a request — but \$arguments is filled in two phases (path values at match-time, full resolved set before the action) so making it mutable saves us reconstructing the RouteMatch and re-pushing it onto the context. Safe because the RouteMatch lives on the per-request context container (coroutine-local under Swoole), so only the request's own coroutine touches it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splitting that change out into its own PR. RouteMatch::\$arguments is now empty during init hooks again; the validated set still lands in \$arguments right before the action runs. Reverts the relevant parts of a2c5636 ("Seed RouteMatch::\$arguments with path values at match-time"). Keeps the mutability change from 6340582 since it's orthogonal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Adapter::getContainer() / getContext() split was a foot-gun: two methods with the same return type, distinguishable only by docs. Downstream code that called getContainer() expecting per-request behavior silently got the global one after the rename, leading to 500-on-every-request bugs. Reduce the public API surface: - Adapter exposes only getContext(): smart-routed (request-scoped during a request, global otherwise), parent-chain falls through to the global for singleton lookups. - The global container is constructor-injected and not retrievable post-construction. Adapters keep their own private reference. - Http captures the global at construction via getContext() — the invariant being that Http is constructed at boot, never inside a request, so getContext() returns the global directly there. Make Http::setContext() public so application code (not just the framework) can register per-request values. Symmetric with the already-public Http::setResource(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setContext() is now public on Http (utopia-php/http#251). The graphql resolver no longer needs to reach into the per-request container directly to swap the active match for nested resolution — it can just ask Http to do it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier draft claimed \$arguments gets pre-populated with path values at match-time so init hooks can read them. That seeding pass was never merged (split out into a follow-up PR), so the docblock was lying about behavior. Rewrite to describe what actually happens: \$arguments is empty until execute() writes it once, just before the route action runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most of the explanatory blocks I added are restating obvious behavior or duplicating what the PR description covers. Reduce to one-liners where intent isn't already clear from the code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a validator throws partway through param resolution (the normal 400-validation path), \$match->arguments would have been left empty under the previous all-or-nothing assignment. Error hooks reading it saw nothing. Add a by-ref \$resolved out-param to getArguments() that gets written *before* validate() runs for each param. The route call site binds it to \$match->arguments, so a mid-loop throw leaves everything resolved up to that point visible to downstream error hooks. Other call sites omit the param. Restores the partial-read behavior the old Hook::setParamValue writeback gave us, race-free this time. Added testErrorHookSeesPartialMatchArgumentsWhenValidationFails. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Under the Swoole and SwooleCoroutine adapters, multiple requests run concurrently in the same PHP process and share a single
Httpinstance plus the staticRouter/Route/Hookregistries. Today, per-request data is written onto those shared objects — when a coroutine yields on I/O, another coroutine clobbers it. This PR removes four such mutations and replaces the one valid use case with a race-free equivalent.Races fixed
Http::$route— instance property holding "the current route." Two coroutines on the sameHttpoverwrote each other;getRoute()and the telemetry attribution inrun()saw the wrong route after any yield. Moved into the per-request context container (coroutine-local viaCoroutine::getContext()in both Swoole adapters).Route::$matchedPath—Router::match()mutated the singletonRoutestored inRouter::$routesviasetMatchedPath(). Concurrent hits on the same route through different aliases / wildcard segments raced, breaking path-param extraction inexecute().Router::match()now returns the matched route alongside the prepared-path string in an immutable value class; the singleton is no longer mutated.Http::$wildcardRoute— the wildcard branch did$route->path($path)on the singleton, so two concurrent wildcard requests fought over$path. Now weclonethe wildcard route per request before stamping the URI.Hook::setParamValue()writeback inHttp::getArguments()— every resolved param value was written back onto the sharedHook/Route. Removed. Replaced with the per-requestRouteMatch::$argumentsmap (see below).The
RouteMatchvalue classRouting state — the matched
Route, the prepared-path string it was matched under, and the name-keyed argument map — is one logical thing, so this PR bundles it into a single value class:$routeand$pathare immutable.$argumentsis mutable so the framework can fill it in once the action's params resolve, without rebuilding theRouteMatch. Safe because the RouteMatch lives on the per-request context container (coroutine-local under Swoole), so only the request's own coroutine touches it.Lifecycle:
Router::match()returns?RouteMatch(with empty$arguments).Http::match()stores it on the per-request context container under'match'(ornullif no match).Http::execute()writes$argumentswith the full resolved+validated argument map (path + query + body, post-validator) — sameRouteMatchinstance, no replacement on the context.RouteMatcharound the cloned wildcard route.run()reads it once at the end.Terminology: resources vs. context
This PR formalizes a split that was already implicit in the codebase:
Http::setResource()/Http::getResource()— singletons on the global container. Same as before.Http::setContext()(now public) — per-request values on the adapter's request container, which is coroutine-local under the Swoole adapters. Replaces the oldsetRequestResource()(protected, internal-only).On the adapter, there is exactly one public reader:
Adapter::getContext(): Container— returns the container for the current execution context. Inside a request, that's the per-request container (coroutine-local under Swoole), with parent-chain fallback to the global one for singleton lookups. Outside a request (boot,onStarthooks), it's the global container directly.The global container is constructor-injected into the adapter and not retrievable post-construction. There is no public
getContainer()— callers don't need to know which scope they're in;getContext()returns the right one for where they are.The framework writes the following keys on the context container:
request/responserunInternal()Request/Responseerrorinit/shutdown/runInternal/onStart)\Throwablematchnullat the top ofrunInternal(). Set to aRouteMatchoncematch()resolves;$argumentsis filled with the resolved+validated set right before the action runs. The sameRouteMatchinstance is reused throughout the request.RouteMatch, ornull(no route matched, or read before routing)Inject
matchinto any hook/action:Breaking changes
This PR ships eight breaking changes. All are public-facing but localized.
1.
Router::match()return typeBefore:
public static function match(string $method, string $path): ?RouteAfter:
public static function match(string $method, string $path): ?RouteMatch2.
Http::getRoute()/Http::setRoute()removedBoth methods are gone. The current route is part of the
RouteMatchvalue class on the context now.$http->getResource('match')?->routeor inject'match'.RouteMatchduringmatch()/runInternal(); application code shouldn't.3.
Hookparam values no longer populated by request handlingHttp::getArguments()no longer calls$hook->setParamValue($key, $value). If you called$hook->getParamValue($key)or$hook->getParamsValues()from inside a route action /init/shutdown/errorhook expecting it to return the resolved value for the current request, it will now returnnull.Replacement: inject
'match'and read$match->arguments['key']. The framework populates this with the resolved+validated argument map right before the action runs, so shutdown / error hooks see the same values the action received. Race-free because theRouteMatchlives on the per-request context container (coroutine-local under Swoole), so only the request's own coroutine touches it.inithooks see$match->argumentsas empty — they run before the route's params are resolved. The full validated map is available from the action onward (and to all subsequent shutdown / error hooks). A follow-up PR will surface path values earlier so init hooks can read URI segments without falling back toRoute::getPathValues().4.
Route::setMatchedPath()/Route::getMatchedPath()removedThese methods (and the underlying
Route::$matchedPathproperty) have been deleted outright. Read it via$match->path.5.
Http::$requestContainerproperty removedThe
protected ?Container $requestContainer = null;field was never assigned by the framework. Removed. If a subclass references it, drop the reference — per-request state lives on the adapter's context ($server->getContext()), which is coroutine-local in the Swoole adapters.6.
setRequestResource()renamed tosetContext()(and now public)Http::setRequestResource()(protected) is nowHttp::setContext()and is public — application code can use it to register per-request values, just as it already could withsetResource()for singletons. Subclasses that overrodesetRequestResource()need to rename their override (and widen visibility if they kept it protected). The Swoole adapters' coroutine-key constantREQUEST_CONTAINER_CONTEXT_KEYis likewise nowCONTEXT_KEY, and the underlying string moved from'__utopia_http_request_container'to'__utopia_http_context'. If a subclass of either adapter referenced these, rename to match.7.
Adapter::getContainer()removed; replaced bygetContext()Adapter::getContainer()is gone. The adapter now exposes onlygetContext(): Container— a single public reader that returns the container for the current execution context (per-request inside a request with parent-fallback to global, global directly outside one). The global container stays constructor-injected and is not retrievable post-construction.Custom
Adaptersubclasses must implementgetContext()and drop theirgetContainer()implementation. Application code calling$server->getContainer()should switch to$server->getContext(); semantics matchgetContainer()'s old behavior (smart-routed by execution context), so a one-line rename is the whole migration.8. Wildcard
Routeis now a per-request cloneThe wildcard route inside
RouteMatchfor a wildcard handler is a per-request clone ofHttp::$wildcardRoute, not the singleton. Identity comparisons ($match->route === Http::$wildcardRoute) will fail. Property reads (getPath(),getAction(), etc.) behave identically.Migration guide
For typical application code (defining routes, hooks, handlers), no changes are required unless you read the current route or used
$hook->getParamValue(). The breaks only matter if you call the framework's routing primitives directly or subclassHttp/ the adapters.$route = Router::match($m, $p);$match = Router::match($m, $p); $route = $match?->route;$http->getRoute()from app code$http->getResource('match')?->routeor->inject('match')and read$match->route$http->setRoute($route)from app codematch()/runInternal()$route->getMatchedPath()$match->path$route->setMatchedPath(...)$server->getContainer()(anywhere)$server->getContext()— same smart-routed behavior, just the new nameclass MyAdapter extends Adapter { public function getContainer() {...} }getContext(): Containerinstead; drop thegetContainer()overrideclass MyHttp extends Http { /* reads $this->requestContainer */ }$this->server->getContext()class MyHttp extends Http { protected function setRequestResource(...) }setContext(...)REQUEST_CONTAINER_CONTEXT_KEYCONTEXT_KEY$hook->getParamValue('foo')inside a shutdown/error handler->inject('match')and read$match->arguments['foo']$route === Http::$wildcardRouteinside a wildcard handler$route->getMethod()), or checkHttp::$wildcardRoute !== null && $route->getAction() === Http::$wildcardRoute->getAction()If you run on FPM only, everything keeps working identically — there is no concurrency in FPM, so the races never fire and the new code paths are equivalent to the old ones.
Test plan
vendor/bin/phpunit tests/HttpTest.php tests/RouterTest.php tests/RouteTest.php tests/RequestTest.php— 72/72 pass (addedtestShutdownHookCanInjectResolvedArgumentscovering theRouteMatch::$argumentsflow).vendor/bin/phpstan analyse— clean at level 7.composer format:check— clean (Pint).ResponseFPMTest,ResponseSwooleTest) requires theswoole/fpmDocker hosts fromdocker-compose.yml; unchanged frommainlocally — verify in CI.getResource('match'), (d) a shutdown hook that injects'match'and substitutes labels like{request.fileId}from$match->arguments.🤖 Generated with Claude Code